Plotly in a standalone Slate notebook

Abstract

Existing PlotlyJS.jl code, rendered by Slate and exported to one HTML file that opens with no Julia, no server and no network — figures fully interactive, plotly.js inlined, zero external requests.

Three levels of interactivity, in increasing order of what they ask of the author.

using PlotlyJS, SlatePlotly
"PlotlyJS $(pkgversion(PlotlyJS)) · SlatePlotly $(pkgversion(SlatePlotly))"
"PlotlyJS 0.18.18 · SlatePlotly 0.1.0"
# Monthly inflation: a mild cycle plus one sharp episode, narrow enough that the smoothing window
# visibly changes the peak.
xs = collect(1:60)
infl = round.(2.0 .+ 0.45 .* sin.(xs ./ 3.2) .+ 0.25 .* cos.(xs ./ 1.7) .+
              4.5 .* exp.(-((xs .- 31.0) .^ 2) ./ (2 * 1.1^2)); digits = 2)

# Centred moving average; the window shrinks at the ends rather than dropping points, so the smoothed
# series spans the whole range.
function movavg(v, w)
    half = (w - 1) ÷ 2
    [begin
         lo, hi = max(1, i - half), min(length(v), i + half)
         round(sum(view(v, lo:hi)) / (hi - lo + 1); digits = 2)
     end for i in eachindex(v)]
end

"$(length(xs)) months · peak $(maximum(infl))%"
"60 months · peak 6.59%"

A @bind control that still works with no kernel

The control is an ordinary Slate @bind — it looks and behaves like every other control in the notebook, and other cells can read w too.

Live, moving the slider re-runs the cell below in Julia: movavg(infl, w) is genuinely recomputed.

In a standalone export there is no kernel, so @replay computes the series for every position the control can take, packs them as one binary array, and ships it with the page. Moving the slider then indexes into that data.

The whole extra ask is the macro. The author never names a trace or a field, and never restates the domain — it is read from the control, so it cannot drift out of sync with it.

# The smoothed series is REPLAYABLE: `@replay` computes it for every position the slider can take, ships
# the lot as one packed binary array, and returns the slice for the current `w`. Live this is ordinary
# Julia; in a standalone export the slider indexes the shipped data instead.
#
# Note what is NOT written here: the domain. It comes from the control itself, so it cannot drift.
Plot([scatter(x = xs, y = infl, mode = "lines", name = "monthly",
              line = attr(width = 1), opacity = 0.45),
      scatter(x = xs, y = @replay(w, movavg(infl, w)), mode = "lines",
              name = "smoothed", line = attr(width = 3))],
     Layout(title = "Centred moving average",
            height = 420,
            xaxis = attr(title = "month"),
            yaxis = attr(title = "inflation (%)", range = [0, 7])))

Pushing on it — a matrix, and a different kind of control

@replay is not limited to a series or to a slider. Here a whole matrix (a heatmap's z) is replayed, driven by a Select. The slices stack along a new trailing dimension, so one control value is still a contiguous run in the shipped buffer — the page takes a view and reshapes it, never a gather.

field(k) = [k == "gaussian" ? exp(-((x - 40)^2 + (y - 30)^2) / 320) :
            k == "ripple"   ? sin(hypot(x - 40, y - 30) / 4) :
            k == "saddle"   ? ((x - 40)^2 - (y - 30)^2) / 1600 :
                              -exp(-((x - 40)^2 + (y - 30)^2) / 900)
            for y in 1:60, x in 1:80]

Plot(heatmap(z = @replay(kern, field(kern)), colorscale = "Viridis"),
     Layout(title = "60 × 80 field, replayed across 4 options", height = 420,
            xaxis = attr(title = "x"), yaxis = attr(title = "y")))

How far does this go?

The cost is the whole shipped array: points × domain size × 8 bytes for Float64. That is the only limit — there is no per-position figure, no duplicated spec, and nothing recomputed at export time.

Below: a 2 000-point series over a 100-position slider. That is 200 000 values, 1.6 MB packed — and the control still moves at full speed, because switching position is a contiguous read, not a recomputation.


What survives the export

Export with Offline ticked for a single file with no external subresources. When the notebook has @replay marks, a second dialog appears first: each one lists what it will compute and how many bytes it will carry — measured from a real value, not estimated — and lets you lower a slider's resolution to carry less. That choice is stored in the notebook, so it travels with the file.

In the exported file
Plotly figureslive — hover, legend, zoom/pan, modebar
plotly.js itselfinlined from a version-pinned artifact
A @bind with @replay datalive — the control indexes shipped data
A @bind with no replay datarendered, but disabled — visibly inert rather than silently dead
Any other @bind reactivitynot available; it needs the kernel

A control is enabled only when data for it actually shipped, so a reader can tell at a glance which knobs still do something.

What it costs

The shipped array is points × values × 4 bytes once narrowed to 32-bit at export. The three figures above carry ~1.7 MB at full resolution; at every 5th on the largest one, ~470 kB. Compression is optional — it saves roughly a further 18% but needs a 2023-or-newer browser to read, so a page bound for an old locked-down machine should leave it off.